"""
:mod:`misc.py` -- ArcGIS Python Toolbox Tool "misc.py"

Notes
----------
TODO SYPNOPSIS
:synopsis:
:authors: Riley Baird (OK), Emma Baker (OK)
:created: August 02, 2024
:modified:  January 02, 2025
"""

import logging
from collections.abc import Sequence, Iterable
from enum import StrEnum
from pathlib import Path
from typing import Optional, overload, get_args, Literal, Any, TypeAlias, Self, cast, TYPE_CHECKING, TypeVar, Protocol

import arcpy
import attrs
import pandas as pd
# noinspection PyUnresolvedReferences
from arcgis.features import GeoAccessor, GeoSeriesAccessor
from arcpy import ArcSDESQLExecute, Extent, Field, FieldInfo, FieldMap, FieldMappings, Index, Parameter, Point, RandomNumberGenerator, RecordSet, SpatialReference
# noinspection PyProtectedMember
from pandas._libs.missing import NAType

# Must be imported after arcpy
from datetime import datetime, date, time

from .iterablenamespace import FrozenDict

if TYPE_CHECKING:
    from .config_dataclasses import NG911Field


_logger = logging.getLogger(__name__)

GPParameterValue: TypeAlias = Optional[ArcSDESQLExecute | Extent | Field | FieldInfo | FieldMap | FieldMappings | Index | Point | RandomNumberGenerator | RecordSet | SpatialReference | bool | float | int | str | datetime]

# required_dataset_name = config.gdb_info.required_dataset_name
# optional_dataset_name = config.gdb_info.optional_dataset_name


class LoadDataFrame:
    def __init__(self, fc_path):
        self.fc_path = fc_path
        self.df = pd.DataFrame.spatial.from_featureclass(fc_path)

    def fix_df_fields(self, fields) -> pd.DataFrame:
        for col_field in list(self.df.columns):
            # drop fields not in fields list
            if col_field in fields or 'shape' in col_field.lower():
                # skip desired fields or shape field
                continue
            self.df.drop(col_field, axis = 1, inplace=True)
        return self.df


class Parity(StrEnum):
    ODD = "ODD"
    EVEN = "EVEN"
    BOTH = "BOTH"
    ZERO = "ZERO"

    @classmethod
    def from_modulo_result(cls, value: int, use_na: bool = False) -> Self | None | NAType:
        if value == 0:
            return cls.EVEN
        elif value == 1:
            return cls.ODD
        elif use_na:
            return pd.NA
        else:
            return None

    @classmethod
    def from_modulo_result_series(cls, series: pd.Series) -> pd.Series:
        # noinspection PyTypeChecker
        return series.apply(cls.from_modulo_result, use_na=True).astype("addressrange")

    # def __str__(self) -> str:
    #     return self.value


class NGUIDAssignMethod(StrEnum):
    """
    Methods of NGUID assignment.
    """
    NGUID = "NGUID"
    """Method in which the local ID is extracted from an existing NGUID, which
    is then used with a provided agency ID to build a complete NGUID. Requires
    populated :ng911field:`agency_id` and NGUID fields. Conversion from v2.2
    NGUIDs is supported with this method."""

    LOCAL = "LOCAL"
    """Method in which the local ID and agency ID are provided and used to
    build a complete NGUID. Requires populated :ng911field:`local_id` and
    :ng911field:`agency_id` fields."""

    SEQUENTIAL = "SEQUENTIAL"
    """Method in which sequential numbers, starting with 1, are assigned as the
    local IDs and used with a provided agency ID to build a complete NGUID.
    Requires a populated :ng911field:`agency_id` field."""

    COPY = "COPY"
    """Method that simply copies existing NGUID attributes to an NGUID field.
    Intended for field mapping operations."""

    NULL = "NULL"
    """NGUID attributes are set to null."""


def check_user_fc_for_fields(fc_path: Optional[str], parameter_field_table: list[list[str]] | list[str], error_list: list[str], std_idx: int = 0, user_idx: int = 1) -> tuple[list[str], list[list[str]]]:
    #                                                                       ^^^ correct ^^^   ^ wrong ^
    # The type hint indicated above is not accurate, but it's there because the
    # type-checker will complain based on what it expects from the ``values``
    # attribute of an ``arcpy.Parameter`` instance
    cast(list[list[str]], parameter_field_table)

    new_field_value_list: list[list[str]] = []
    field_filter_list: list[str] = [error_list[0]]

    if fc_path:
        try:
            field_filter_list = [field.name for field in arcpy.Describe(fc_path).fields]
        except:
            field_filter_list = [error_list[1]]

    value_list: list[str]
    for value_list in parameter_field_table:
        append_list: list[str] = value_list
        std_field_name = value_list[std_idx]

        if len(field_filter_list) == 1 and field_filter_list[0] in error_list[:2]:
            user_field = field_filter_list[0]
        else:
            if error_list[2] not in field_filter_list:
                field_filter_list = [error_list[2]] + field_filter_list
            try:
                field_to_map_list = [field_name for field_name in field_filter_list if field_name.upper() == std_field_name.upper()]
                if len(field_to_map_list) == 1:
                    user_field = field_to_map_list[0]
                else:
                    user_field = error_list[2]
            except:
                user_field = error_list[2]

        append_list[std_idx] = std_field_name
        append_list[user_idx] = user_field
        new_field_value_list.append(append_list)

    return field_filter_list, new_field_value_list


def get_field_info_for_fc_creation(fc_fields) -> (list[list[Optional[str | int]]]):
    field_description = []
    for fc_field in fc_fields:
        # field = config.get_field_by_name(fc_field)
        field_name = fc_field.name
        field_type = fc_field.type
        field_length = fc_field.length
        field_domain = fc_field.domain.name if fc_field.domain else None
        default_value = None
        current_field_list = [field_name, field_type, field_name, field_length, default_value, field_domain]
        field_description.append(current_field_list)
    return field_description


class MixedZeroParityError(Exception):
    """
    Exception for use in the following situations involving inconsistent
    parity:

    * Given an address range, one bound is zero, but the other is non-zero.
    * Given a parity and an address range, the parity is zero, but one or both
      range bounds are non-zero.
    * Given a parity and an address range, the parity is not zero, but one or
      both range bounds are zero.
    """
    pass


@overload
def calculate_parity(data: int) -> Literal[Parity.ODD, Parity.EVEN, Parity.ZERO]: ...

@overload
def calculate_parity(data: Sequence[int], summarize: Literal[False]) -> list[Literal[Parity.ODD, Parity.EVEN, Parity.ZERO]]: ...

@overload
def calculate_parity(data: Sequence[int], summarize: Literal[True]) -> Parity: ...

def calculate_parity(data, summarize=False):
    if isinstance(data, Sequence):
        if not all(isinstance(item, int) for item in data):
            raise TypeError("A sequence passed to calculate_parity() must only contain instances of int.")
        if set(data) == {0}:
            return Parity.ZERO

        parities = [calculate_parity(number) for number in data]

        if not summarize:
            return parities
        else:
            parities = set(parities)

        # At this point, summarize must be True
        if parities == {Parity.ODD}:
            return Parity.ODD
        elif parities == {Parity.EVEN}:
            return Parity.EVEN
        elif parities == {Parity.ODD, Parity.EVEN}:
            return Parity.BOTH
        elif parities == {Parity.ZERO}:
            return Parity.ZERO
        elif Parity.ZERO in parities:
            raise MixedZeroParityError("Mixed zero and non-zero values passed to calculate_parity().")
        else:
            raise RuntimeError(f"Could not determine parity of {data}.")

    elif data == 0:
        return Parity.ZERO
    elif data & 1 == 1:
        return Parity.ODD
    elif data & 1 == 0:
        return Parity.EVEN
    else:
        raise RuntimeError(f"Could not determine parity of {data}.")

@overload
def unwrap(target: Parameter) -> GPParameterValue | list[GPParameterValue]: ...

@overload
def unwrap(target: Sequence[Parameter]) -> list[GPParameterValue | list[GPParameterValue]]: ...

def unwrap(target):
    """
    Extracts the value(s) from ``arcpy.Parameter`` object(s).

    :param target: Parameter object or Sequence thereof
    :type target: Union[Parameter, Sequence[Parameter]]
    :return: List of usable objects representing the inputs
    :rtype: Union[GPParameterValue, list[GPParameterValue]]
    """
    try:
        if isinstance(target, get_args(GPParameterValue)):
            # If target is an unwrapped type, return it
            return target
        elif type(target).__qualname__ == "geoprocessing parameter object":
            from arcpy.arcobjects.arcobjectconversion import convertArcObjectToPythonObject
            return unwrap(convertArcObjectToPythonObject(target))
        elif hasattr(target, "__class__") and isinstance(target, Sequence) and not isinstance(target, str) and all(map(lambda x: isinstance(x, Parameter) or type(x).__qualname__ == "geoprocessing parameter object", target)):
            # Filter out Parameter objects becuase checking if a Parameter is an instance of Sequence results in an error
            # If target is a Sequence of Parameter objects, run this function on target's contents.
            # Strings are Sequences; filter them out
            return [*map(unwrap, target)]
        elif isinstance(target, Parameter) or hasattr(target, "isEmpty"):
            # If target appears to be a Parameter object or a Value object, search
            # for attributes 'values' and 'value'. If neither exist, fall back to
            # simply returning target.
            if hasattr(target, "values"):
                return unwrap(target.values)
            elif hasattr(target, "value"):
                return unwrap(target.value)
            else:
                arcpy.AddWarning(f"Encountered error extracting value from a parameter. Converting to string. Value: '{str(target)}'")
                return str(target)
        else:
            return target
    except Exception as ex:
        raise ex

def unwrap_to_dict(targets: Sequence[arcpy.Parameter]) -> dict[str, GPParameterValue | list[GPParameterValue]]:
    return {p.name: unwrap(p) for p in targets}

def params_to_dict(targets: Sequence[arcpy.Parameter]) -> dict[str, arcpy.Parameter]:
    return {p.name: p for p in targets}

def na_eq(val1: Any, val2: Any):
    """
    Compares any two values, testing for equality. Unline the ``==`` operator,
    however, this function will return ``True`` if both *val1* and *val2* are
    considered ``NA`` by ``pandas``, such as ``pandas.NA`` and ``numpy.nan``.

    :param val1: Any object
    :type val1: Any
    :param val2: Any object
    :type val2: Any
    :return: Whether *val1* and *val2* are equal or both ``NA``
    :rtype: bool
    """
    if pd.isna(val1) and pd.isna(val2):
        return True
    elif pd.isna(val1) or pd.isna(val2):
        return False
    else:
        return val1 == val2

def na_in_list(val1: Any, in1: Any):
    # if pd.isna(val1) and pd.isna(in1):
    #     return True
    if pd.isna(val1) or any(pd.isna(in1)):
        return False
    else:
        return val1 in in1


def na_in_set(in1: Any, in2: Any):
    if pd.isna(in1) and pd.isna(in2):
        return True
    elif pd.isna(in1) or pd.isna(in2):
        return False
    else:
        return in1 & in2

_T_Field = TypeVar("_T_Field", str, "NG911Field")


class _AttrsInstanceWithAllowedFields(Protocol):
    __attrs_attrs__: tuple
    __ng911_allowed_fields__: Sequence["NG911Field | str"]


class _AttrsInstanceWithFeatureClassPath(Protocol):
    __attrs_attrs__: tuple
    __ng911_feature_class_path__: Path


def validate_field_exists(instance: _AttrsInstanceWithAllowedFields | _AttrsInstanceWithFeatureClassPath, attribute: "attrs.Attribute[_T_Field]", value: _T_Field) -> None:
    """
    A function compatible with the ``validator`` argument of ``attrs.Field`` in
    cases where a field name or :class:`~NG911Field` instance is passed as the
    value of the ``attrs.Field``. A ``ValueError`` is raised if *value* is not
    among the allowed/existing fields.

    :param instance: An ``attrs`` object with either an
        ``__ng911_allowed_fields__`` attribute or an
        ``__ng911_feature_class_path__`` attribute
    :param attribute: The ``attrs.Attribute`` object
    :param value: Value of the attribute
    """
    from .config_dataclasses import NG911Field
    if not attrs.has(instance.__class__):
        raise TypeError(f"'instance' is not an instance of an attrs-decorated class.")
    if hasattr(instance, "__ng911_allowed_fields__"):
        allowed_values: set[str] = {f.name if isinstance(f, NG911Field) else f for f in instance.__ng911_allowed_fields__}
    elif hasattr(instance, "__ng911_feature_class_path__"):
        allowed_values: set[str] = {f.name for f in arcpy.ListFields(instance.__ng911_feature_class_path__)}
    else:
        raise AttributeError("'instance' must have an attribute called either '__ng911_allowed_fields__' or '__ng911_feature_class_path__'.")
    if value not in allowed_values:
        raise ValueError(f"Field '{value}' does not exist in feature class or is not allowed to be used here.")

FeatureAttributeValue: TypeAlias = str | int | float | datetime | date | time | arcpy.Geometry | None
RowTuple: TypeAlias = tuple[FeatureAttributeValue, ...]
ArcFieldTypeStr: TypeAlias = Literal["Geometry", "SmallInteger", "Integer", "BigInteger", "Single", "Double", "String", "Date", "DateOnly", "TimeOnly", "TimestampOffset", "Guid", "Raster"]
ArcFieldTypeKeyword: TypeAlias = Literal["SHORT", "LONG", "BIGINTEGER", "FLOAT", "DOUBLE", "TEXT", "DATE", "DATEHIGHPRECISION", "DATEONLY", "TIMEONLY", "TIMESTAMPOFFSET", "BLOB", "GUID", "RASTER"]

FIELD_DATA_TYPES: FrozenDict[ArcFieldTypeKeyword, ArcFieldTypeStr] = FrozenDict({
    "SHORT": "SmallInteger",
    "LONG": "Integer",
    "BIGINTEGER": "BigInteger",
    "FLOAT": "Single",
    "DOUBLE": "Double",
    "TEXT": "String",
    "DATE": "Date",
    "DATEHIGHPRECISION": "Date",
    "DATEONLY": "DateOnly",
    "TIMEONLY": "TimeOnly",
    "TIMESTAMPOFFSET": "TimestampOffset",
    "BLOB": "Blob",
    "GUID": "Guid",
    "RASTER": "Raster"
})
"""Mapping of field data type keywords where keys are those used in field creation and values are the corresponding keywords found in ``arcpy.Field.type`` (and ``config.yml``)."""

FIELD_TYPE_KEYWORDS: FrozenDict[ArcFieldTypeStr, ArcFieldTypeKeyword] = FrozenDict({
    "SmallInteger": "SHORT",
    "Integer": "LONG",
    "BigInteger": "BIGINTEGER",
    "Single": "FLOAT",
    "Double": "DOUBLE",
    "String": "TEXT",
    # "Date": "DATE",
    "Date": "DATEHIGHPRECISION",
    "DateOnly": "DATEONLY",
    "TimeOnly": "TIMEONLY",
    "TimestampOffset": "TIMESTAMPOFFSET",
    "Blob": "BLOB",
    "Guid": "GUID",
    "Raster": "RASTER"
})
"""Mapping of field data type keywords where keys are those found in ``arcpy.Field.type`` (and ``config.yml``) and values are the corresponding keywords used in field creation. In this mapping, ``Date`` is mapped to ``DATEHIGHPRECISION``, not ``DATE``."""

DOMAIN_TYPES: FrozenDict[str, str] = FrozenDict({
    "CODED": "CodedValue",
    "RANGE": "Range"
})
"""Mapping of domain type keywords where keys are those used in domain creation and values are the corresponding keywords found in ``arcpy.da.Domain.domainType``."""

ValidationCategory: TypeAlias = Literal["Geodatabase", "General Feature Class", "Address Point", "Road Centerline"]
"""The categories under which validation routines are grouped."""

Severity: TypeAlias = Literal["Notice", "Warning", "Error"]
"""The three severity levels of :class:`ValidationErrorMessage` instances."""

GDBErrorCode: TypeAlias = Literal[  # SEVERITY:CATEGORY:DESCRIPTION
    "ERROR:PYTHON:EXCEPTION",  # For use when a routine encounters an error
    "ERROR:GDB:MISSING_REQUIRED_DATASET",
    "ERROR:GDB:MISSING_REQUIRED_FEATURE_CLASS",
    "ERROR:GDB:EXTRA_ITEM",
    "ERROR:GDB:MISSING_DOMAIN",
    "ERROR:GDB:EXTRA_DOMAIN",
    "ERROR:GDB:INCORRECT_DOMAIN_TYPE",
    "ERROR:GDB:DOMAIN_MISSING_CODE",
    "ERROR:GDB:DOMAIN_EXTRA_CODE",
    "ERROR:GDB:DOMAIN_CODE_VALUE_MISMATCH",
    "ERROR:GDB:INCORRECT_DOMAIN_DESCRIPTION",
    "ERROR:DATASET:INCORRECT_SPATIAL_REFERENCE",
    "ERROR:DATASET:MISSING_TOPOLOGY",
    "ERROR:DATASET:INCORRECT_TOPOLOGY",
    "ERROR:DATASET:TOPOLOGY_VIOLATION",
    "ERROR:FEATURE_CLASS:MISSING_REQUIRED_FIELD",
    "ERROR:FEATURE_CLASS:EXTRA_FIELD",
    "ERROR:FEATURE_CLASS:INCORRECT_SPATIAL_REFERENCE",
    "ERROR:FEATURE_CLASS:INCORRECT_GEOMETRY_TYPE",
    "ERROR:FEATURE_CLASS:INCORRECT_FEATURE_TYPE",
    "ERROR:FEATURE_CLASS:EMPTY",
    "ERROR:FEATURE_CLASS:EMPTY_SUBMISSION",
    "ERROR:FIELD:INCORRECT_FIELD_TYPE",
    "ERROR:FIELD:INCORRECT_FIELD_LENGTH",
    "ERROR:FIELD:INCORRECT_FIELD_DOMAIN",
    "NOTICE:GDB:MISSING_OPTIONAL_DATASET",
    "NOTICE:GDB:MISSING_OPTIONAL_FEATURE_CLASS"
]
"""The specific error codes for use with :class:`GDBErrorMessage`. These values should be succinct indicators of the type of error that a Toolkit user could look up in an error glossary for more information than can reasonably be provided in the error table."""

FeatureAttributeErrorCode: TypeAlias = Literal[  # SEVERITY:CHECK:DESCRIPTION
    "ERROR:DOMAIN:INVALID_VALUE",
    "ERROR:GENERAL:INVALID_VALUE",
    "ERROR:GENERAL:MANDATORY_IS_NULL",
    "ERROR:GENERAL:MANDATORY_IS_BLANK",
    "ERROR:GENERAL:NOT_UPPERCASE",
    "ERROR:GENERAL:UNIQUENESS",
    "WARNING:GENERAL:LEADING_TRAILING_SPACE",
    "ERROR:NGUID:FORMAT",
    "ERROR:NGUID:V2_FORMAT",  # For use with NGUIDFormatError
    "ERROR:NGUID:AGENCY",
    "ERROR:NGUID:LAYER",
    "ERROR:NGUID:DUPLICATE",
    "ERROR:ADDRESS:DUPLICATE",
    "ERROR:ADDRESS_RANGE:OVERLAP",
    "ERROR:ADDRESS_RANGE:DECREASING",
    "ERROR:ROAD_ESN:DEVIATION",
    "ERROR:ROAD_ESN:CROSSING",
    "ERROR:ROAD_ESN:OUT_OF_BOUNDS",
    "ERROR:PARITY:EXPECTED_ZERO",
    "ERROR:PARITY:EXPECTED_NONZERO",
    "ERROR:PARITY:MISMATCH",
    "ERROR:PARITY:INVALID",
    "ERROR:PARITY:NULL",
    "ERROR:LEGACY:MISMATCH",
    "ERROR:GEOCODE:UNKNOWN_MATCH",
    "ERROR:GEOCODE:WRONG_SIDE",
    "ERROR:GEOCODE:BOTH_SIDES",
    "ERROR:GEOCODE:WRONG_COMMUNITY",
    "ERROR:GEOCODE:OUT_OF_RANGE",
    "ERROR:GEOCODE:NAME_MISMATCH",
    "ERROR:CONSISTENCY:ADDRESS_ESN",
    "ERROR:CONSISTENCY:ROAD_ESN",
    "ERROR:CONSISTENCY:COMMUNITY",
    "WARNING:CONSISTENCY:ROAD_LEVEL",
    "WARNING:CONSISTENCY:ROAD_ESN",
    "ERROR:GEOMETRY:TOPOLOGY",
    "WARNING:GEOMETRY:CUTBACK",
    "WARNING:GEOMETRY:SHORT_SEGMENT",
    "NOTICE:CONSISTENCY:ROAD_ESN",
]
"""The specific error codes for use with :class:`FeatureAttributeErrorMessage`. These values should be succinct indicators of the type of error that a Toolkit user could look up in an error glossary for more information than can reasonably be provided in the error table."""


def all_values_selected(parameter: arcpy.Parameter) -> bool:
    """Returns whether a multivalue ``arcpy.Parameter`` has all values in its
    filter list selected."""
    if not hasattr(parameter, "values"):
        raise AttributeError("Provided Parameter does not have attribute 'values'.")
    if not parameter.filter.list:
        raise ValueError("Provided Parameter does not have any values in filter list.")
    values = parameter.values or []  # In case parameter.values is None
    return set(values) == set(parameter.filter.list)

def arc_field_details(field: arcpy.Field) -> str:
    attributes = ("name", "type", "length", "domain")
    attribute_detail = ", ".join(
        f"{a}='{v}'" if isinstance(v := getattr(field, a), str)
        else f"{a}={v}"
        for a in attributes
    )
    if isinstance(field, arcpy.Field):
        return f"<arcpy.Field: {attribute_detail}>"
    else:
        _logger.warning("An object was passed to arc_field_details() that is not an instance of arcpy.Field.", stack_info=True)
        return f"<{str(type(field))}: {attribute_detail}>"


class AttributeConversionError(ValueError):
    def __init__(self, original_value: FeatureAttributeValue, target_field: "NG911Field", *args):
        self.original_value: FeatureAttributeValue = original_value
        self.target_field: "NG911Field" = target_field
        super().__init__(*args)

_T_FAV = TypeVar("_T_FAV", bound=FeatureAttributeValue, covariant=True)
def convert_attribute_value(value: _T_FAV, target_field: "NG911Field") -> FeatureAttributeValue:
    """
    Given an attribute value from a feature, attempt to convert it to the
    appropriate type indicated by *target_field*.

    :param value: Attribute value to convert
    :param target_field: Field that *value* should be valid for
    :return: Equivalent value of appropriate type
    :raises AttributeConversionError: If *value* is not valid for *target_field*
    """
    fail = lambda message: AttributeConversionError(value, target_field, message)
    original_value: _T_FAV = value
    error: AttributeConversionError | None = None
    match target_field.type:
        # Process the input value as appropriate for the data type of the standard field
        case "SmallInteger":
            value = int(value)
            if not (-2 ** 15 <= value < 2 ** 15):
                error = fail(f"Numeric value {value} out of range for field type '{target_field.type}'.")
        case "Integer":
            value = int(value)
            if not (-2 ** 31 <= value < 2 ** 31):
                error = fail(f"Numeric value {value} out of range for field type '{target_field.type}'.")
        case "BigInteger":
            value = int(value)
            if not (-2 ** 53 <= value <= 2 ** 53):  # Both <= comparisons and exponents of 53 are intentional
                raise fail(f"Numeric value {value} out of range for field type '{target_field.type}'.")
        case "Single" | "Double":
            value = float(value)
        case "String":
            value = str(value)
            if len(value) > target_field.length:
                error =  fail(f"Value '{value}' too long for field {target_field.name}.")
        case "Date" | "TimestampOffset":
            if not isinstance(value, datetime):
                error =  fail(f"Value '{value}' is not a datetime object.")
        case "DateOnly":
            if isinstance(value, date):
                pass
            elif isinstance(value, datetime):
                value = value.date()
            else:
                error =  fail(f"Could not convert value '{value}' to a date.")
        case "TimeOnly":
            if not isinstance(value, time):
                error =  fail(f"Value '{value}' is not a time object.")
    if error:
        error.add_note(f"Destination Field: {target_field.name}")
        error.add_note(f"Original Value: {original_value}")
        error.add_note(f"Original Type: {type(original_value)}")
        raise error
    return value

def strjoin(strings: Iterable[str], joiner: str, *, q=False, qq=False) -> str:
    if {" ", "n", "t", ",", ";"} - set(joiner):
        raise ValueError("Only spaces, commas, semicolons, and the letters 'n' and 't' are allowed in joiner.")
    if q and qq:
        raise ValueError("Cannot combine 'q' and 'qq'.")
    elif q:
        strings = quote(strings)
    elif qq:
        strings = qquote(strings)
    joiner = joiner.replace("n", "\n").replace("t", "\t")
    return joiner.join(strings)

def wrap_string(string: str, *wrappers: str) -> str:
    """Surrounds *string* with *wrappers*. If one argument is provided for
    *wrappers*, it will be placed both before and after *string*. If two
    arguments are provided, the first will be prepended and the second will be
    appended to *string*."""
    if len(wrappers) == 1:
        left = wrappers[0]
        right = wrappers[0]
    elif len(wrappers) == 2:
        left, right = wrappers
    else:
        raise ValueError(f"Either 1 or 2 arguments should be provided for 'wrappers'; got {len(wrappers)}.")
    return f"{left}{string}{right}"

def wrap_strings(strings: Iterable[str], *wrappers: str) -> list[str]:
    """Surrounds each member of *strings* with *wrappers*. If one argument is
    provided for *wrappers*, it will be placed both before and after each
    member of *strings*. If two arguments are provided, the first will be
    prepended and the second will be appended to each member of *strings*."""
    return [wrap_string(string, *wrappers) for string in strings]

def quote(strings: Iterable[str]) -> list[str]:
    """Surrounds each string in *strings* with single quotes."""
    return [f"'{x}'" for x in strings]

def qquote(strings: Iterable[str]) -> list[str]:
    """Surrounds each string in *strings* with double quotes."""
    return [f'"{x}"' for x in strings]

_T_indent = TypeVar("_T_indent", str, Iterable[str])
def indent(lines: _T_indent) -> _T_indent:
    if single_string := isinstance(lines, str):
        lines: list[str] = lines.splitlines()
    lines = [f"\t{line}" for line in lines]
    return "\n".join(lines) if single_string else lines

# _T_Protocol_co = TypeVar("_T_Protocol_co", bound=type[Protocol], covariant=True)
# def confirm_attributes(typ: type[_T_Protocol_co]) -> type[_T_Protocol_co]:
#     """Decorator that ensures instances of *typ* have the attributes
#     specified in any ``Protocol``\ s in the type's ``__bases__``."""
#     attributes = [a for t in typ.__bases__ if getattr(t, "_is_protocol", False) for a in t.__annotations__.keys()]
#     __original_init__ = typ.__init__
#     @wraps(typ.__init__)
#     def __init__(self, /, *args, **kwargs):
#         self.__original_init__(*args, **kwargs)
#         if x := [y for y in [1, 2, 3] if y > 3]:
#             print(x)
#         if missing_attributes := [x for x in attributes if not hasattr(self, x)]:
#             print(missing_attributes)
#             raise AttributeError(f"Instance is missing attributes: {', '.join(missing_attributes)}.")
#     # setattr(typ, "__original_init__", __original_init__)
#     typ.__init__ = __init__
#     return typ